SPB Git forge

spb/countryatlas

Public
20commits 1branches 0releases
268.3 MBsize
maindefault branch
13 days agolast push
TypeScript 57% Python 38.6% JavaScript 3.6% CSS 0.6%
24.9 KB · 425 lines tsx
Raw Blame History
1import { Download, ExternalLink } from 'lucide-react';2import type { Metadata } from 'next';3import Link from 'next/link';4import { notFound } from 'next/navigation';5import { t, tOpt } from '@/i18n';6import { api, isNotBuilt, isNotFound, safe } from '@/lib/api';7import { apiAnalytics } from '@/lib/api-analytics';8import { apiExplore } from '@/lib/api-explore';9import { formatDate, formatPct, formatValue, grouped, isNum } from '@/lib/format';10import { jsonLd, jsonLdString, seoTitle } from '@/lib/seo';11import { SITE_URL, routes } from '@/lib/site';12import { topicById } from '@/lib/topics';13import type { CountrySummary, FormatSpec, Series } from '@/lib/types';14import type { FramesResponse } from '@/lib/types-analytics';15import type { IndicatorResponse, RankedValue } from '@/lib/types-explore';16import { RankRace } from '@/components/charts/rank-race';17import { RankedBars, rankedRowFromCountry, type RankedBarRow } from '@/components/charts/ranked-bars';18import { EmptyState, NotBuiltState } from '@/components/data/empty-state';19import type { ProvenancePayload } from '@/components/data/provenance-context';20import { QualityBadges } from '@/components/data/quality-badge';21import { Section } from '@/components/data/section';22import { CodeBlock } from '@/components/explore/copy-button';23import { ACTION_CLS, PageHeader } from '@/components/explore/page-header';24import { DistributionPanel } from '@/components/indicators/distribution-panel';25import { IndicatorCompare, type CompareCountry } from '@/components/indicators/indicator-compare';26import { IndicatorMap } from '@/components/indicators/indicator-map';27import { IndicatorTrend } from '@/components/indicators/indicator-trend';28import { baseFeatures } from '@/components/indicators/map-geometry';29import { RelatedTable } from '@/components/indicators/related-table';3031export const revalidate = 900;3233type Params = { slug: string };34type SP = Record<string, string | string[] | undefined>;3536async function load(slug: string): Promise<IndicatorResponse | 'not-built' | null> {37  try {38    return await apiExplore.indicator(slug);39  } catch (e) {40    if (isNotFound(e)) return null;41    if (isNotBuilt(e)) return 'not-built';42    throw e;43  }44}4546export async function generateMetadata({ params }: { params: Promise<Params> }): Promise<Metadata> {47  const { slug } = await params;48  const data = await load(slug);49  if (!data || data === 'not-built') return { title: t('indicator.notFound'), robots: { index: false } };50  const ind = data.indicator;51  const name = titleCase(ind.short_name ?? ind.name ?? slug);52  const title = seoTitle.indicator(name);53  const description = t('indicator.description', { name: ind.name ?? name, unit: ind.unit ?? '', n: data.coverage.n_countries ?? 0, y0: data.years.first ?? '', y1: data.years.last_actual ?? data.years.last ?? '' });54  const canonical = routes.indicator(ind.slug);55  return {56    title: { absolute: `${title} | ${t('site.name')}` },57    description,58    alternates: { canonical },59    openGraph: { title: `${title} | ${t('site.name')}`, description, url: canonical, type: 'article' },60    twitter: { card: 'summary_large_image', title, description },61  };62}6364/** "Life expectancy" → "Life Expectancy"; keeps acronyms (GDP, CO₂) and short function words lower-case. */65function titleCase(s: string): string {66  const small = new Set(['of', 'per', 'to', 'in', 'at', 'by', 'and', 'or', 'the', 'a', 'an', 'vs', 'on', 'for', 'as']);67  return s68    .split(' ')69    .map((w, i) => (i > 0 && small.has(w.toLowerCase()) ? w.toLowerCase() : w === w.toUpperCase() ? w : w.charAt(0).toUpperCase() + w.slice(1)))70    .join(' ');71}7273function specOf(ind: IndicatorResponse['indicator']): FormatSpec {74  return { format: ind.format, unit: ind.unit, unit_short: ind.unit_short, precision: ind.precision, frequency: ind.frequency, name: ind.short_name ?? ind.name, higher_is_better: ind.higher_is_better };75}7677/** 10-year movers from the frames payload: change between the latest frame and the frame ten years earlier (± 2). */78function decadeMovers(frames: FramesResponse | null, countries: Map<string, CountrySummary>, hib: boolean | null | undefined, relative: boolean): { up: RankedBarRow[]; down: RankedBarRow[]; from: number; to: number } | null {79  if (!frames || frames.years.length < 11) return null;80  const years = frames.years;81  const toIdx = years.length - 1;82  const to = years[toIdx]!;83  let fromIdx = years.findIndex((y) => y >= to - 10);84  if (fromIdx < 0 || fromIdx === toIdx) return null;85  if (years[fromIdx]! > to - 8) fromIdx = Math.max(0, fromIdx - 1);86  const from = years[fromIdx]!;87  const rows: Array<{ id: string; delta: number; v0: number; v1: number }> = [];88  for (const [iso, arr] of Object.entries(frames.values)) {89    const v1 = arr[toIdx];90    const v0 = arr[fromIdx];91    if (typeof v1 !== 'number' || typeof v0 !== 'number') continue;92    const pop = countries.get(iso)?.population_latest ?? 0;93    if (pop < 1_000_000) continue;94    const delta = relative ? (v0 !== 0 ? ((v1 - v0) / Math.abs(v0)) * 100 : NaN) : v1 - v0;95    if (!Number.isFinite(delta)) continue;96    rows.push({ id: iso, delta, v0, v1 });97  }98  if (rows.length < 10) return null;99  const sorted = [...rows].sort((a, b) => b.delta - a.delta);100  const mk = (r: { id: string; delta: number; v0: number; v1: number }, i: number): RankedBarRow => {101    const c = countries.get(r.id);102    return { id: r.id, label: c?.name ?? r.id, flag: c?.flag ?? null, href: c?.slug ? routes.country(c.slug) : null, value: r.delta, rank: i + 1 };103  };104  const up = sorted.slice(0, 6).map(mk);105  const down = sorted.slice(-6).reverse().map(mk);106  // Direction semantics: when higher is better, "up" is the improvement list; when lower is better, swap.107  return { up: hib === false ? down : up, down: hib === false ? up : down, from, to };108}109110export default async function IndicatorPage({ params, searchParams }: { params: Promise<Params>; searchParams: Promise<SP> }) {111  const [{ slug }, sp] = await Promise.all([params, searchParams]);112  const data = await load(slug);113  if (data === null) notFound();114  if (data === 'not-built') return <NotBuiltState />;115116  const ind = data.indicator;117  const name = ind.name ?? ind.slug;118  const spec = specOf(ind);119  const yearUsed = data.years.latest_common ?? data.world_latest?.year ?? data.years.last_actual ?? null;120  const highlight = typeof sp.country === 'string' && /^[a-z0-9-]+$/i.test(sp.country) ? sp.country : null;121122  const [countriesRes, frames, trend, related, quality, distribution, race] = await Promise.all([123    safe(api.countries()),124    safe(apiAnalytics.indicatorFrames(ind.slug)),125    safe(apiExplore.indicatorTrend(ind.slug, 'world')),126    safe(apiAnalytics.indicatorRelated(ind.slug, { limit: 10 })),127    safe(apiAnalytics.indicatorQuality(ind.slug)),128    safe(apiAnalytics.indicatorDistribution(ind.slug, { highlight })),129    ind.ranking_eligible !== false ? safe(apiAnalytics.race(ind.slug, { top: 10 })) : Promise.resolve(null),130  ]);131  const countries = countriesRes?.items ?? [];132  const onlyCountries = countries.filter((c) => (c.kind ?? 'country') === 'country');133  const byId = new Map(countries.map((c) => [c.id, c]));134135  // Default comparison: the 3 largest economies that have data for this indicator.136  const lastIdx = frames ? frames.years.length - 1 : -1;137  const hasValue = (id: string) => (frames && lastIdx >= 0 ? typeof frames.values[id]?.[lastIdx] === 'number' : false);138  const defaults: CompareCountry[] = [...onlyCountries]139    .filter((c) => isNum(c.gdp_latest) && hasValue(c.id))140    .sort((a, b) => (b.gdp_latest ?? 0) - (a.gdp_latest ?? 0))141    .slice(0, 3)142    .map((c) => ({ id: c.id, slug: c.slug ?? c.id, name: c.name ?? c.id, flag: c.flag }));143  const bundle = defaults.length ? await safe(apiExplore.seriesBundle(defaults.map((c) => c.id), [ind.slug])) : null;144  const initialSeries: Series[] = bundle?.series ?? [];145  const { features, sphere } = countries.length ? baseFeatures(countries) : { features: [], sphere: '' };146147  const wl = data.world_latest;148  const worldPayload: ProvenancePayload = {149    indicator: { slug: ind.slug, name, format: ind.format, unit: ind.unit, unit_short: ind.unit_short, frequency: ind.frequency, higher_is_better: ind.higher_is_better, methodology: ind.methodology, description: ind.description },150    value: wl ? { value: wl.value, formatted: wl.formatted, period: wl.year ? `${wl.year}-01-01` : null, year: wl.year, unit: ind.unit, provenance: frames?.provenance ?? data.top5[0]?.provenance ?? null } : null,151    country: null,152    downloadHref: routes.indicatorDownload(ind.slug),153  };154155  const hib = ind.higher_is_better;156  const topLabel = hib == null ? t('indicator.top.highest') : t('indicator.top.best');157  const bottomLabel = hib == null ? t('indicator.top.lowest') : t('indicator.top.worst');158  const toRows = (rows: RankedValue[]) => rows.map((r) => rankedRowFromCountry(r.country, r.value, r.rank));159  const relative = ['currency', 'number', 'tonnes', 'kwh'].includes(ind.format ?? '');160  const movers = decadeMovers(frames, byId, hib, relative);161  const deltaSpec: FormatSpec = relative ? { format: 'percent', precision: 0, unit: '%' } : { format: ind.format === 'percent' || ind.format === 'years' || ind.format === 'index' || ind.format === 'ratio' ? ind.format : ind.format, unit: ind.unit, unit_short: ind.unit_short, precision: ind.precision };162163  const missing: CountrySummary[] = frames && lastIdx >= 0 ? onlyCountries.filter((c) => !hasValue(c.id)).sort((a, b) => (b.population_latest ?? 0) - (a.population_latest ?? 0)) : [];164  const topic = topicById(ind.topic ?? '');165  const apiSnippet = `curl -s "${SITE_URL}/api/v1/series?country=CAN&indicator=${ind.slug}" | jq '.series[0] | {country: .country.name, unit, last: .stats.last, source: .provenance.source_name}'`;166  const freqLabel = tOpt(`indicator.frequency.${ind.frequency ?? 'A'}`, ind.frequency ?? 'A');167  const worldKindLabel = wl ? tOpt(`indicator.world.${wl.kind}`, t('indicator.world', { kind: wl.kind })) : null;168  const ld = [169    jsonLd.dataset({ slug: ind.slug, name: ind.name ?? name, description: ind.description, unit: ind.unit, firstYear: data.years.first, lastYear: data.years.last_actual, nCountries: data.coverage.n_countries, sources: data.sources.map((s) => ({ name: s.source_name, url: s.url, licence: s.licence })), modified: data.meta.built_at }),170    jsonLd.breadcrumbs([{ name: t('indicators.title'), path: routes.indicators() }, ...(topic ? [{ name: topic.short, path: routes.indicators(topic.id) }] : []), { name: ind.short_name ?? name, path: routes.indicator(ind.slug) }]),171  ];172173  return (174    <>175      <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: jsonLdString(ld) }} />176      <PageHeader177        crumbs={[{ href: routes.indicators(), label: t('indicators.title') }, ...(topic ? [{ href: routes.indicators(topic.id), label: topic.short }] : [])]}178        eyebrow={[topic?.name, ind.subtopic].filter(Boolean).join(' · ')}179        title={name}180        lede={ind.description}181        meta={182          <span className="flex flex-wrap items-center gap-x-2 gap-y-1">183            <span>184              {ind.unit} · {freqLabel} · {t('indicator.coverage', { n: grouped(data.coverage.n_countries ?? 0), total: grouped(data.coverage.n_countries_total), pct: formatPct(data.coverage.coverage_pct) })}185              {data.years.first && data.years.last_actual ? ` · ${data.years.first}–${data.years.last_actual}` : ''}186              {data.sources[0]?.source_name ? ` · ${data.sources[0].source_name}` : ''}187            </span>188            <QualityBadges badges={quality?.badges ?? null} />189          </span>190        }191        actions={192          <>193            {ind.ranking_eligible !== false ? (194              <Link href={routes.ranking(ind.slug)} className={ACTION_CLS}>195                {t('common.ranking')}196              </Link>197            ) : null}198            <Link href={routes.explore({ indicator: ind.slug })} className={ACTION_CLS}>199              {t('nav.explore')}200            </Link>201            <a href={routes.indicatorDownload(ind.slug)} className={ACTION_CLS}>202              <Download size={14} aria-hidden /> {t('common.downloadCsv')}203            </a>204          </>205        }206      />207208      {/* World figure + freshness */}209      <section aria-label={t('indicator.freshness')} className="border-y border-rule">210        <dl className="grid grid-cols-2 gap-y-4 py-4 sm:grid-cols-4 lg:divide-x lg:divide-rule">211          <div className="col-span-2 sm:col-span-1 lg:pr-4">212            <dt className="eyebrow">{worldKindLabel ?? t('indicator.world.median')}</dt>213            <dd className="pnum mt-1 text-2xl font-semibold leading-none text-ink md:text-3xl">{formatValue(wl?.value, spec)}</dd>214            {wl?.year ? <dd className="tnum mt-1 text-xs text-ink-3">{t('indicator.world.n', { n: grouped(wl.n ?? 0), year: wl.year })}</dd> : null}215          </div>216          <div className="lg:px-4">217            <dt className="eyebrow">{t('indicator.freshness.latest')}</dt>218            <dd className="tnum mt-1 text-base font-semibold text-ink">{data.years.last_actual ?? t('common.na')}</dd>219            {data.years.last && data.years.last_actual && data.years.last > data.years.last_actual ? <dd className="text-2xs text-ink-3">{t('common.forecast')} → {data.years.last}</dd> : null}220            {quality ? <dd className="tnum text-2xs text-ink-3">{t('indicator.quality.years', { n: quality.years_with_50plus })}</dd> : null}221          </div>222          <div className="lg:px-4">223            <dt className="eyebrow">{t('indicator.freshness.source')}</dt>224            <dd className="tnum mt-1 text-base font-semibold text-ink">{formatDate(data.freshness.source_updated_at)}</dd>225            {quality ? <dd className="tnum text-2xs text-ink-3">{t('indicator.quality.flagged', { n: grouped(quality.flagged_values) })}</dd> : null}226          </div>227          <div className="lg:pl-4">228            <dt className="eyebrow">{t('indicator.freshness.refreshed')}</dt>229            <dd className="tnum mt-1 text-base font-semibold text-ink">{formatDate(data.freshness.retrieved_at ?? data.freshness.built_at)}</dd>230            {data.meta.run_id ? <dd className="text-2xs text-ink-3">{t('site.footer.build', { run: data.meta.run_id })}</dd> : null}231          </div>232        </dl>233      </section>234235      <Section id="map" title={t('indicator.map.title')} subtitle={t('indicator.map.sub')} className="border-t-0">236        {features.length ? <IndicatorMap slug={ind.slug} geometry={features} sphere={sphere} frames={frames} spec={spec} payload={worldPayload} initialYear={yearUsed} /> : <EmptyState title={t('common.noDataLong')} />}237      </Section>238239      <Section id="trend" title={t('indicator.trend.title')} subtitle={t('indicator.trend.sub')}>240        <IndicatorTrend slug={ind.slug} initial={trend} spec={spec} payload={worldPayload} />241      </Section>242243      <Section id="top" title={t('indicator.top.title')} subtitle={yearUsed ? t('indicator.top.sub', { year: yearUsed }) : undefined} actions={ind.ranking_eligible !== false ? <Link href={routes.ranking(ind.slug)} className="text-accent hover:underline">{t('indicator.top.fullRanking')} →</Link> : null}>244        {data.top5.length === 0 ? (245          <EmptyState compact title={t('common.noDataLong')} />246        ) : (247          <div className="grid gap-x-10 gap-y-6 lg:grid-cols-2">248            <div>249              <h3 className="mb-2 text-sm font-semibold text-ink">{topLabel}</h3>250              <RankedBars rows={toRows(data.top5)} spec={spec} provenance={data.top5[0]?.provenance ?? null} />251            </div>252            {data.bottom5.length ? (253              <div>254                <h3 className="mb-2 text-sm font-semibold text-ink">{bottomLabel}</h3>255                <RankedBars rows={toRows(data.bottom5)} spec={spec} provenance={data.bottom5[0]?.provenance ?? null} />256              </div>257            ) : null}258          </div>259        )}260      </Section>261262      {movers ? (263        <Section id="movers" title={t('indicator.movers.title', { y0: movers.from, y1: movers.to })} subtitle={hib == null ? t('indicator.movers.subNeutral') : t('indicator.movers.sub')}>264          <div className="grid gap-x-10 gap-y-6 lg:grid-cols-2">265            <div>266              <h3 className="mb-2 flex items-center gap-1.5 text-sm font-semibold text-ink">267                <span aria-hidden className={hib == null ? 'text-inc' : 'text-up'}>↑</span> {hib == null ? t('indicator.movers.largestIncrease') : t('indicator.movers.fastestImproving')}268              </h3>269              <RankedBars rows={movers.up} spec={deltaSpec} showRank={false} />270            </div>271            <div>272              <h3 className="mb-2 flex items-center gap-1.5 text-sm font-semibold text-ink">273                <span aria-hidden className={hib == null ? 'text-dec' : 'text-down'}>↓</span> {hib == null ? t('indicator.movers.largestDecrease') : t('indicator.movers.fastestDeclining')}274              </h3>275              <RankedBars rows={movers.down} spec={deltaSpec} showRank={false} />276            </div>277          </div>278          <p className="mt-3 text-xs text-ink-3">{t('indicator.movers.note', { y0: movers.from, y1: movers.to, kind: relative ? t('indicator.movers.relative') : t('indicator.movers.absolute') })}</p>279        </Section>280      ) : null}281282      {distribution && distribution.n >= 10 ? (283        <Section id="distribution" title={t('indicator.dist.section')} subtitle={t('indicator.dist.sectionSub')}>284          <DistributionPanel data={distribution} spec={spec} />285        </Section>286      ) : null}287288      {race && race.frames.length >= 20 ? (289        <Section id="race" title={t('indicator.race.title')} subtitle={t('ranking.race.sub', { name: spec.name ?? name, y0: race.years[0] ?? '', y1: race.years[race.years.length - 1] ?? '', top: race.top })}>290          <RankRace data={race} spec={spec} top={10} initialYear={yearUsed} />291        </Section>292      ) : null}293294      <Section id="compare" title={t('indicator.compare.title')} subtitle={t('indicator.compare.sub')}>295        <IndicatorCompare slug={ind.slug} spec={spec} initialCountries={defaults} initialSeries={initialSeries} payload={worldPayload} />296      </Section>297298      <Section id="related" title={t('indicator.related.statTitle')} subtitle={t('indicator.related.statSub')} actions={<Link href={routes.scatter({ x: ind.slug })} className="text-accent hover:underline">{t('indicator.related.openScatter')} →</Link>}>299        {related ? <RelatedTable data={related} slug={ind.slug} /> : <p className="text-sm text-ink-3">{t('indicator.related.noneStat')}</p>}300      </Section>301302      <Section id="definition" title={t('indicator.definition')} level={2}>303        <dl className="grid gap-x-8 gap-y-3 text-sm sm:grid-cols-2 lg:grid-cols-3">304          <div>305            <dt className="eyebrow">{t('indicator.definition')}</dt>306            <dd className="mt-0.5 text-ink-2">{ind.description ?? t('common.na')}</dd>307          </div>308          {ind.methodology ? (309            <div>310              <dt className="eyebrow">{t('indicator.methodology')}</dt>311              <dd className="mt-0.5 text-ink-2">{ind.methodology}</dd>312            </div>313          ) : null}314          <div>315            <dt className="eyebrow">{t('indicator.unit')}</dt>316            <dd className="mt-0.5 text-ink">317              {ind.unit ?? t('common.na')}318              {ind.unit_short && ind.unit_short !== ind.unit ? <span className="text-ink-3"> ({ind.unit_short})</span> : null}319            </dd>320          </div>321          <div>322            <dt className="eyebrow">{t('indicator.frequency')}</dt>323            <dd className="mt-0.5 text-ink">{freqLabel}</dd>324          </div>325          <div>326            <dt className="eyebrow">{t('indicator.aggregation')}</dt>327            <dd className="mt-0.5 text-ink">328              {ind.aggregation ?? 'none'} · {t(`indicator.higherIsBetter.${hib == null ? 'null' : hib ? 'true' : 'false'}` as 'indicator.higherIsBetter.null')}329            </dd>330          </div>331          {ind.bounds && (ind.bounds[0] != null || ind.bounds[1] != null) ? (332            <div>333              <dt className="eyebrow">{t('indicator.bounds')}</dt>334              <dd className="tnum mt-0.5 text-ink">335                {ind.bounds[0] ?? '−∞'} – {ind.bounds[1] ?? '∞'}336              </dd>337            </div>338          ) : null}339        </dl>340      </Section>341342      <Section id="sources" title={t('indicator.sources.title')} subtitle={t('indicator.sources.sub')}>343        <ol className="divide-y divide-rule border-y border-rule">344          {data.sources.map((s) => (345            <li key={`${s.source_id}-${s.series_code}`} className="grid gap-x-6 gap-y-1 py-3 sm:grid-cols-[2.5rem_minmax(0,1fr)_auto] sm:items-start">346              <span className="tnum text-xs text-ink-3">347                <span className="sr-only">{t('indicator.sources.priority')} </span>#{s.priority ?? '—'}348              </span>349              <div className="min-w-0 text-sm">350                <div className="flex flex-wrap items-baseline gap-x-2">351                  <Link href={routes.source(s.source_id)} className="link-quiet inline-flex min-h-[44px] items-center font-medium text-ink md:min-h-0">352                    {s.source_name ?? s.source_id}353                  </Link>354                  <span className="text-ink-3">{s.dataset}</span>355                  <code className="break-all rounded-xs bg-surface-2 px-1 font-mono text-xs text-ink-2">{s.series_code}</code>356                  {s.last_status ? <span className="text-2xs uppercase tracking-wide text-ink-3">{s.last_status}</span> : null}357                </div>358                <div className="tnum mt-0.5 text-xs text-ink-3">359                  {s.n_countries != null ? t('indicators.coverageShort', { n: grouped(s.n_countries) }) : ''}360                  {s.n_observations != null ? ` · ${grouped(s.n_observations)} obs.` : ''}361                  {s.last_year ? ` · → ${s.last_year}` : ''}362                  {s.licence ? ` · ${s.licence}` : ''}363                  {s.transform ? ` · ${t('indicator.sources.transform', { expr: s.transform })}` : ''}364                  {s.countries?.length ? ` · ${t('indicator.sources.restricted', { n: s.countries.length })}` : ''}365                </div>366                {s.notes ? <p className="mt-1 line-clamp-2 text-xs text-ink-3">{s.notes}</p> : null}367              </div>368              {s.url ? (369                <a href={s.url} target="_blank" rel="noopener noreferrer" className="inline-flex min-h-[44px] items-center gap-1 text-xs text-accent hover:underline md:min-h-[32px]">370                  <ExternalLink size={12} aria-hidden /> {t('indicator.sources.open')}371                </a>372              ) : null}373            </li>374          ))}375        </ol>376        <p className="mt-3 max-w-prose text-xs leading-relaxed text-ink-3">{t('indicator.sources.rule')}</p>377      </Section>378379      <div className="grid gap-x-10 lg:grid-cols-2">380        <Section id="no-data" title={t('indicator.nodata.title')} subtitle={frames && yearUsed ? (missing.length ? t('indicator.nodata.sub', { n: grouped(missing.length), total: grouped(onlyCountries.length), year: frames.years[lastIdx] ?? yearUsed }) : t('indicator.nodata.none', { year: frames.years[lastIdx] ?? yearUsed })) : undefined}>381          {missing.length ? (382            <>383              <ul className="flex flex-wrap gap-1.5 text-xs">384                {missing.slice(0, 24).map((c) => (385                  <li key={c.id}>386                    <Link href={routes.country(c.slug ?? c.id)} className="inline-flex min-h-[32px] items-center gap-1 rounded-sm border border-rule px-2 text-ink-2 hover:border-accent hover:text-accent">387                      <span aria-hidden>{c.flag}</span> {c.name}388                    </Link>389                  </li>390                ))}391                {missing.length > 24 ? <li className="inline-flex min-h-[32px] items-center px-1 text-ink-3">{t('indicator.nodata.more', { n: missing.length - 24 })}</li> : null}392              </ul>393              <p className="mt-3 max-w-prose text-xs leading-relaxed text-ink-3">{t('indicator.nodata.why')}</p>394            </>395          ) : (396            <p className="text-sm text-ink-2">{t('indicator.nodata.why')}</p>397          )}398        </Section>399400        <Section id="downloads" title={t('indicator.downloads.title')} subtitle={t('indicator.downloads.sub')}>401          <div className="flex flex-wrap gap-2">402            <a href={routes.indicatorDownload(ind.slug, 'csv')} className={ACTION_CLS}>403              <Download size={14} aria-hidden /> {t('indicator.downloads.csv')}404            </a>405            <a href={routes.indicatorDownload(ind.slug, 'json')} className={ACTION_CLS}>406              <Download size={14} aria-hidden /> {t('indicator.downloads.json')}407            </a>408            <Link href={routes.download({ indicators: ind.slug })} className={ACTION_CLS}>409              {t('indicator.downloads.builder')}410            </Link>411          </div>412          <h3 className="mt-5 text-sm font-semibold text-ink">{t('indicator.downloads.api')}</h3>413          <p className="mb-2 text-xs text-ink-3">{t('indicator.downloads.apiHint')}</p>414          <CodeBlock code={apiSnippet} lang="bash" />415          <p className="mt-2 text-xs text-ink-3">416            <Link href={routes.api()} className="text-accent hover:underline">417              {t('data.api.link')} →418            </Link>419          </p>420        </Section>421      </div>422    </>423  );424}425